Skip to content

fix(sticker): send WebP stickers as-is instead of re-encoding them - #166

Open
FlavioPulli wants to merge 2 commits into
evolution-foundation:developfrom
FlavioPulli:fix/animated-sticker-passthrough
Open

fix(sticker): send WebP stickers as-is instead of re-encoding them#166
FlavioPulli wants to merge 2 commits into
evolution-foundation:developfrom
FlavioPulli:fix/animated-sticker-passthrough

Conversation

@FlavioPulli

@FlavioPulli FlavioPulli commented Aug 6, 2026

Copy link
Copy Markdown

Problem

SendSticker runs every sticker through convertToWebP: fetch the URL, decode with image.Decode, re-encode with webp.Encode(Quality: 80). That is wrong whenever the source is already a WebP — which is every sticker that came from WhatsApp itself.

  1. Animated stickers cannot be sent at all. The registered decoder (chai2010/webp) only reads static WebP, so an animated file fails and the whole send dies with:
    failed to convert image to WebP: failed to decode image: webpDecodeRGBA: failed
    
  2. The ones that do go through lose quality for nothing — a perfectly valid WebP is decoded and re-compressed at 80%.

How often it bites

Measured on a real deployment. We classified the 100 sticker files that had arrived in one instance, by the VP8X animation flag (flags & 0x02) and by counting ANMF chunks:

count
animated, 2+ frames 61
animation flag set, 1 frame only 4
PNG (evolution-go converts these on arrival) 28
static WebP 9

65 of 100 received stickers could not be re-sent. Not an edge case — it is the majority of what a support agent can forward back to a customer.

The 4 single-frame ones deserve a mention, because they are the ones that make this look like a product bug rather than a limitation: the file sits in the animated container but does not move, so the user sees a perfectly still sticker being rejected as "animated".

Change

If the downloaded bytes are already a valid WebP, they are uploaded untouched — animation and quality survive. Non-WebP input (PNG, JPEG) still goes through the conversion path, unchanged.

StickerMessage.IsAnimated is now set from the container flags; without it the recipient's client renders the first frame as a still image.

Two defensive details that the passthrough makes necessary:

  • The download is capped with an io.LimitReader. http.Get + io.ReadAll was unbounded, and now that the payload is uploaded rather than decoded, nothing downstream constrains its size either.
  • isWebP validates the declared RIFF size against the buffer length. The old conversion path rejected a truncated download for free (a truncated file fails to decode); a partial body still carries valid RIFF/WEBP magic and would be uploaded as-is, reaching the recipient broken.

One new file plus two lines at the call site, to keep rebasing cheap.

Testing

Verified end to end against a live instance: a sticker that previously failed with the error above was sent, delivered and read, with the animation intact on the recipient's device. go build ./... and go vet ./pkg/sendMessage/... clean on develop.

Relation to #151

@nicolasnovis got here first — #151 (2026-07-31) fixes the same bug the same way, and I only found it after writing this. It is based on main, and on #128 @iagocotta asked that PRs target develop, which is why this one exists separately rather than as a comment.

I have no attachment to which one lands. If #151 is retargeted to develop, I will close this and it can carry the fix; the two hardening bits above would then be worth folding in (they are the only substantive difference). Maintainers' call.

Summary by Sourcery

Handle sticker sending by passing through existing WebP stickers unchanged, while still converting non-WebP images, and mark animated stickers correctly.

New Features:

  • Detect and preserve animation metadata on sent stickers by setting StickerMessage.IsAnimated based on the WebP container flags.

Bug Fixes:

  • Allow animated WebP stickers received from URLs to be sent without failing on decoding or losing animation.
  • Avoid unnecessary quality loss by no longer re-encoding already-WebP stickers at a fixed quality setting.

Enhancements:

  • Cap remote sticker downloads by size to avoid unbounded memory usage and reject oversized payloads.
  • Validate WebP RIFF container size before passthrough to avoid forwarding truncated or corrupted sticker files.

`SendSticker` ran every sticker through `convertToWebP`, which fetches the URL,
decodes with `image.Decode` and re-encodes with `webp.Encode(Quality: 80)`. That
is wrong whenever the source is already a WebP — which is every sticker that came
from WhatsApp itself:

1. Animated stickers cannot be sent at all. The registered decoder
   (chai2010/webp) only reads static WebP, so an animated file fails with
   `webpDecodeRGBA: failed` and the whole send dies.
2. The ones that do go through lose quality for nothing: a perfectly valid WebP is
   decoded and re-compressed at 80%.

If the downloaded bytes are already a valid WebP they are now uploaded untouched,
and `StickerMessage.IsAnimated` is set from the container flags — without it the
recipient's client renders the first frame as a still image. Non-WebP input (PNG,
JPEG) still goes through the conversion path, unchanged.

How often this bites, measured on a real deployment: of 100 sticker files received
by one instance, classified by the VP8X animation flag and by counting ANMF
chunks, 61 were animated with 2+ frames and 4 carried the animation flag with a
single frame — so 65 of 100 could not be re-sent. The single-frame ones are worth
noting because they look perfectly still to the user, which makes the failure read
as a bug in the product rather than a limitation.

Two defensive details that the passthrough makes necessary:

- The download is capped with an `io.LimitReader`. `http.Get` + `io.ReadAll` was
  unbounded, and now that the payload is uploaded rather than decoded, nothing
  downstream constrains its size either.
- `isWebP` validates the declared RIFF size against the buffer length. The old
  conversion path rejected a truncated download for free (a truncated file fails
  to decode); a partial body still carries valid RIFF/WEBP magic and would be
  uploaded as-is, reaching the recipient broken.

Verified end to end against a live instance: a sticker that previously failed with
the error above was sent, delivered and read, with the animation intact.
@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR changes sticker sending so that WebP stickers are passed through without re-encoding, adds animation detection, and hardens sticker download and validation, while keeping non-WebP inputs on the existing conversion path.

File-Level Changes

Change Details Files
Introduce stickerWebP flow that conditionally passes through existing WebP data or converts other formats to WebP, with size limiting and RIFF validation.
  • Replace SendSticker’s call to convertToWebP with stickerWebP, still returning WebP bytes ready to upload.
  • Implement stickerWebP to HTTP GET the sticker URL, read the body through an io.LimitReader capped at 10 MiB, and fail if the limit is exceeded.
  • If the downloaded data is a valid WebP, return it unchanged; otherwise decode via image.Decode and re-encode using chai2010/webp at quality 80, preserving previous behavior for non-WebP inputs.
  • Add isWebP helper that checks RIFF/WEBP magic and validates that the declared RIFF payload size does not exceed the buffer length to avoid accepting truncated files.
pkg/sendMessage/service/send_service.go
pkg/sendMessage/service/sticker_webp.go
Detect animated WebP stickers and mark StickerMessage.IsAnimated accordingly so clients render animation correctly.
  • Add webpIsAnimated helper that inspects VP8X extended header flags (0x02 animation flag) to determine if a WebP is animated, after confirming the container is valid via isWebP.
  • Set IsAnimated on the StickerMessage payload in SendSticker based on webpIsAnimated(filedata).
pkg/sendMessage/service/send_service.go
pkg/sendMessage/service/sticker_webp.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • Consider replacing the bare http.Get in stickerWebP with a context-aware client (or passing through the existing request context) so sticker downloads respect timeouts and cancellations instead of potentially hanging indefinitely.
  • The error message "failed to convert image to WebP" in SendSticker is now misleading when the input is already WebP and not re-encoded; updating it to something more generic like "failed to prepare sticker payload" would better reflect the new behavior.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider replacing the bare `http.Get` in `stickerWebP` with a context-aware client (or passing through the existing request context) so sticker downloads respect timeouts and cancellations instead of potentially hanging indefinitely.
- The error message `"failed to convert image to WebP"` in `SendSticker` is now misleading when the input is already WebP and not re-encoded; updating it to something more generic like `"failed to prepare sticker payload"` would better reflect the new behavior.

## Individual Comments

### Comment 1
<location path="pkg/sendMessage/service/sticker_webp.go" line_range="38" />
<code_context>
+// A source that is already a valid WebP is returned untouched; anything else is decoded and
+// encoded to WebP as before.
+func stickerWebP(url string) ([]byte, error) {
+	resp, err := http.Get(url)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch image from URL: %v", err)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider using a context-aware HTTP client and checking the response status before reading the body.

This helper now does network I/O with `http.Get`, which uses the default client (no context, no timeout) and doesn’t validate the HTTP status. To avoid hangs and misinterpreting error pages as image data, please use a context-aware client (or one with a reasonable timeout, preferably passed in) and check `resp.StatusCode`, failing fast on non-2xx responses.

Suggested implementation:

```golang
import (
	"context"
	"fmt"
	"io"
	"net/http"
	"time"
)

// maxStickerBytes caps the sticker download. WhatsApp rejects stickers far smaller than this; the
// limit exists so a hostile URL cannot exhaust the process memory — relevant now that the payload
// is uploaded rather than decoded, so nothing downstream constrains its size either.
const maxStickerBytes = 10 << 20 // 10 MiB

// stickerWebP fetches the sticker URL and returns WebP bytes ready to upload.
//
// A source that is already a valid WebP is returned untouched; anything else is decoded and
// encoded to WebP as before.
func stickerWebP(url string) ([]byte, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to create request for image URL: %v", err)
	}

	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch image from URL: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return nil, fmt.Errorf("failed to fetch image from URL: unexpected HTTP status %s", resp.Status)
	}

	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxStickerBytes+1))
	if err != nil {
		return nil, fmt.Errorf("failed to read image from URL: %v", err)
	}
	if len(raw) > maxStickerBytes {
		return nil, fmt.Errorf("sticker exceeds %d bytes", maxStickerBytes)
	}

```

The edit above assumes this file either had no explicit import block or can be safely updated to include `context`, `fmt`, `io`, `net/http`, and `time` together. If an import block already exists elsewhere in this file, adjust the edit so that:
1. You only add `context` and `time` to the existing `import (...)` block rather than reintroducing `fmt`, `io`, and `net/http`.
2. You avoid duplicating the `import` keyword or any existing imports.

If your project uses a shared or injected `*http.Client`, you may want to replace the inline `client := &http.Client{...}` with that shared client while keeping the `NewRequestWithContext` and status-code check logic intact.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread pkg/sendMessage/service/sticker_webp.go Outdated
Two points from the review, both fair:

- `http.Get` used the default client: no timeout, no context, and no status
  check. The sticker URL comes from the API caller and may point anywhere, so a
  server that accepts the connection and then stalls held the goroutine open
  indefinitely. Now it goes through a client with a 30s timeout via
  `http.NewRequestWithContext`, and a non-2xx response fails immediately —
  without that check an HTML error page was read as image data and failed later,
  deeper, with a decode error that said nothing about the URL having answered 404.

- `failed to convert image to WebP` was describing something that no longer
  happens on the passthrough path, where nothing is converted. It is now
  `failed to prepare sticker payload`.

The context is `context.Background()` at the call site, matching the adjacent
`client.Upload` call. Threading the real request context through `SendSticker`
would change the service interface, so I left it out of this PR — happy to do it
if you would rather have it here.
@FlavioPulli

Copy link
Copy Markdown
Author

Thanks @sourcery-ai — both points were fair, fixed in 7803311.

  • Bounded the download. http.Get used the default client: no timeout, no context, no status check. The sticker URL comes from the API caller and may point anywhere, so a server that accepts the connection and then stalls held the goroutine open indefinitely. It now goes through a client with a 30s timeout via http.NewRequestWithContext, and a non-2xx response fails immediately — without that check an HTML error page was read as image data and failed later, deeper, with a decode error that said nothing about the URL having answered 404.

  • Fixed the misleading error. failed to convert image to WebP was describing something that does not happen on the passthrough path, where nothing is converted. It is now failed to prepare sticker payload.

On the context: it is context.Background() at the call site, matching the adjacent client.Upload call. Threading the real request context through SendSticker would change the service interface, so I kept it out of this PR — glad to add it here if maintainers prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant